Brak opisu

[id].tsx 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. import { useEffect, useMemo, useState } from 'react';
  2. import {
  3. Image,
  4. KeyboardAvoidingView,
  5. Platform,
  6. Pressable,
  7. ScrollView,
  8. StyleSheet,
  9. Switch,
  10. TextInput,
  11. View,
  12. } from 'react-native';
  13. import * as ImagePicker from 'expo-image-picker';
  14. import { ResizeMode, Video } from 'expo-av';
  15. import { useLocalSearchParams } from 'expo-router';
  16. import { ThemedButton } from '@/components/themed-button';
  17. import { ThemedText } from '@/components/themed-text';
  18. import { ThemedView } from '@/components/themed-view';
  19. import { ZoomImageModal } from '@/components/zoom-image-modal';
  20. import { Colors } from '@/constants/theme';
  21. import { useColorScheme } from '@/hooks/use-color-scheme';
  22. import { useTranslation } from '@/localization/i18n';
  23. import { dbPromise, initCoreTables } from '@/services/db';
  24. type TaskRow = {
  25. id: number;
  26. name: string;
  27. description: string | null;
  28. };
  29. type EntryRow = {
  30. id: number;
  31. status: string | null;
  32. notes: string | null;
  33. meta_json: string | null;
  34. completed_at: string | null;
  35. };
  36. type MediaRow = {
  37. uri: string | null;
  38. };
  39. export default function TaskDetailScreen() {
  40. const { t } = useTranslation();
  41. const { id } = useLocalSearchParams<{ id?: string | string[] }>();
  42. const taskId = Number(Array.isArray(id) ? id[0] : id);
  43. const theme = useColorScheme() ?? 'light';
  44. const palette = Colors[theme];
  45. const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []);
  46. const [task, setTask] = useState<TaskRow | null>(null);
  47. const [entryId, setEntryId] = useState<number | null>(null);
  48. const [status, setStatus] = useState('');
  49. const [isDone, setIsDone] = useState(false);
  50. const [notes, setNotes] = useState('');
  51. const [mediaUris, setMediaUris] = useState<string[]>([]);
  52. const [activeUri, setActiveUri] = useState<string | null>(null);
  53. const [zoomUri, setZoomUri] = useState<string | null>(null);
  54. const [saving, setSaving] = useState(false);
  55. const [showSaved, setShowSaved] = useState(false);
  56. useEffect(() => {
  57. let isActive = true;
  58. async function loadTask() {
  59. try {
  60. await initCoreTables();
  61. const db = await dbPromise;
  62. const taskRows = await db.getAllAsync<TaskRow>(
  63. 'SELECT id, name, description FROM daily_tasks WHERE id = ? LIMIT 1;',
  64. taskId
  65. );
  66. const entryRows = await db.getAllAsync<EntryRow>(
  67. `SELECT id, status, notes, meta_json, completed_at
  68. FROM daily_task_entries
  69. WHERE task_id = ? AND substr(completed_at, 1, 10) = ?
  70. LIMIT 1;`,
  71. taskId,
  72. todayKey
  73. );
  74. if (!isActive) return;
  75. setTask(taskRows[0] ?? null);
  76. const entry = entryRows[0];
  77. if (entry) {
  78. setEntryId(entry.id);
  79. setNotes(entry.notes ?? '');
  80. const entryStatus = entry.status ?? '';
  81. setStatus(entryStatus);
  82. setIsDone(entryStatus === 'done');
  83. const mediaRows = await db.getAllAsync<MediaRow>(
  84. 'SELECT uri FROM task_entry_media WHERE entry_id = ? ORDER BY created_at ASC;',
  85. entry.id
  86. );
  87. const media = uniqueMediaUris(mediaRows.map((row) => row.uri).filter(Boolean) as string[]);
  88. const fallback = parseTaskMeta(entry.meta_json)?.photoUri;
  89. const merged = uniqueMediaUris([
  90. ...media,
  91. ...(normalizeMediaUri(fallback) ? [normalizeMediaUri(fallback) as string] : []),
  92. ]);
  93. setMediaUris(merged);
  94. setActiveUri(merged[0] ?? null);
  95. }
  96. } catch (error) {
  97. if (isActive) setStatus(`Error: ${String(error)}`);
  98. }
  99. }
  100. loadTask();
  101. return () => {
  102. isActive = false;
  103. };
  104. }, [taskId, todayKey]);
  105. const inputStyle = [
  106. styles.input,
  107. {
  108. borderColor: palette.border,
  109. backgroundColor: palette.input,
  110. color: palette.text,
  111. },
  112. ];
  113. async function handleSave(nextStatus?: string) {
  114. if (!task) return;
  115. try {
  116. setSaving(true);
  117. const db = await dbPromise;
  118. const now = new Date().toISOString();
  119. const statusValue = nextStatus ?? (isDone ? 'done' : 'open');
  120. let currentEntryId = entryId;
  121. if (!currentEntryId) {
  122. const result = await db.runAsync(
  123. 'INSERT INTO daily_task_entries (task_id, field_id, notes, status, completed_at, created_at, meta_json) VALUES (?, NULL, ?, ?, ?, ?, ?);',
  124. task.id,
  125. notes.trim() || null,
  126. statusValue,
  127. now,
  128. now,
  129. serializeTaskMeta({ photoUri: mediaUris[0] })
  130. );
  131. currentEntryId = Number(result.lastInsertRowId);
  132. setEntryId(currentEntryId);
  133. } else {
  134. await db.runAsync(
  135. 'UPDATE daily_task_entries SET notes = ?, status = ?, completed_at = ?, meta_json = ? WHERE id = ?;',
  136. notes.trim() || null,
  137. statusValue,
  138. now,
  139. serializeTaskMeta({ photoUri: mediaUris[0] }),
  140. currentEntryId
  141. );
  142. }
  143. if (currentEntryId) {
  144. await db.runAsync('DELETE FROM task_entry_media WHERE entry_id = ?;', currentEntryId);
  145. for (const uri of uniqueMediaUris(mediaUris)) {
  146. await db.runAsync(
  147. 'INSERT INTO task_entry_media (entry_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  148. currentEntryId,
  149. uri,
  150. isVideoUri(uri) ? 'video' : 'image',
  151. now
  152. );
  153. }
  154. }
  155. setStatus(statusValue === 'done' ? t('tasks.done') : t('tasks.open'));
  156. setShowSaved(true);
  157. setTimeout(() => {
  158. setShowSaved(false);
  159. setStatus('');
  160. }, 1800);
  161. } catch (error) {
  162. setStatus(`Error: ${String(error)}`);
  163. } finally {
  164. setSaving(false);
  165. }
  166. }
  167. return (
  168. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  169. <KeyboardAvoidingView
  170. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  171. style={styles.keyboardAvoid}>
  172. <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
  173. <ThemedText type="title">{task?.name ?? t('tasks.title')}</ThemedText>
  174. {task?.description ? <ThemedText>{task.description}</ThemedText> : null}
  175. <View style={styles.statusRow}>
  176. <ThemedText>{t('tasks.done')}</ThemedText>
  177. <Switch
  178. value={isDone}
  179. onValueChange={(value) => setIsDone(value)}
  180. trackColor={{ false: palette.border, true: palette.tint }}
  181. thumbColor={palette.card}
  182. />
  183. </View>
  184. <ThemedText>{t('tasks.notePlaceholder')}</ThemedText>
  185. <TextInput
  186. value={notes}
  187. onChangeText={setNotes}
  188. placeholder={t('tasks.notePlaceholder')}
  189. placeholderTextColor={palette.placeholder}
  190. style={inputStyle}
  191. multiline
  192. />
  193. <ThemedText>{t('tasks.addMedia')}</ThemedText>
  194. {normalizeMediaUri(activeUri) ? (
  195. isVideoUri(normalizeMediaUri(activeUri) as string) ? (
  196. <Video
  197. source={{ uri: normalizeMediaUri(activeUri) as string }}
  198. style={styles.mediaPreview}
  199. useNativeControls
  200. resizeMode={ResizeMode.CONTAIN}
  201. />
  202. ) : (
  203. <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
  204. <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
  205. </Pressable>
  206. )
  207. ) : (
  208. <ThemedText style={styles.photoPlaceholder}>{t('tasks.photo')}</ThemedText>
  209. )}
  210. {mediaUris.length > 0 ? (
  211. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
  212. {mediaUris.map((uri) => (
  213. <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
  214. {isVideoUri(uri) ? (
  215. <View style={styles.videoThumb}>
  216. <ThemedText style={styles.videoThumbText}>▶</ThemedText>
  217. </View>
  218. ) : (
  219. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  220. )}
  221. <Pressable
  222. style={styles.mediaRemove}
  223. onPress={(event) => {
  224. event.stopPropagation();
  225. setMediaUris((prev) => {
  226. const next = prev.filter((item) => item !== uri);
  227. setActiveUri((current) => (current === uri ? next[0] ?? null : current));
  228. return next;
  229. });
  230. }}>
  231. <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
  232. </Pressable>
  233. </Pressable>
  234. ))}
  235. </ScrollView>
  236. ) : null}
  237. <View style={styles.photoRow}>
  238. <ThemedButton
  239. title={t('tasks.pickFromGallery')}
  240. onPress={() =>
  241. handlePickMedia((uris) => {
  242. if (uris.length === 0) return;
  243. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  244. setActiveUri((prev) => prev ?? uris[0]);
  245. })
  246. }
  247. variant="secondary"
  248. />
  249. <ThemedButton
  250. title={t('tasks.takeMedia')}
  251. onPress={() =>
  252. handleTakeMedia((uri) => {
  253. if (!uri) return;
  254. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  255. setActiveUri((prev) => prev ?? uri);
  256. })
  257. }
  258. variant="secondary"
  259. />
  260. </View>
  261. <View style={styles.actions}>
  262. <View style={styles.updateGroup}>
  263. {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('tasks.saved')}</ThemedText> : null}
  264. <ThemedButton
  265. title={saving ? t('tasks.saving') : t('tasks.save')}
  266. onPress={() => handleSave()}
  267. disabled={saving}
  268. />
  269. </View>
  270. </View>
  271. </ScrollView>
  272. </KeyboardAvoidingView>
  273. <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
  274. </ThemedView>
  275. );
  276. }
  277. async function handlePickMedia(onAdd: (uris: string[]) => void) {
  278. const result = await ImagePicker.launchImageLibraryAsync({
  279. mediaTypes: getMediaTypes(),
  280. quality: 1,
  281. allowsMultipleSelection: true,
  282. selectionLimit: 0,
  283. });
  284. if (result.canceled) return;
  285. const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
  286. if (uris.length === 0) return;
  287. onAdd(uris);
  288. }
  289. async function handleTakeMedia(onAdd: (uri: string | null) => void) {
  290. const permission = await ImagePicker.requestCameraPermissionsAsync();
  291. if (!permission.granted) return;
  292. const result = await ImagePicker.launchCameraAsync({
  293. mediaTypes: getMediaTypes(),
  294. quality: 1,
  295. });
  296. if (result.canceled) return;
  297. const asset = result.assets[0];
  298. onAdd(asset.uri);
  299. }
  300. function getMediaTypes() {
  301. const mediaType = (ImagePicker as {
  302. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  303. }).MediaType;
  304. const imageType = mediaType?.Image ?? mediaType?.Images;
  305. const videoType = mediaType?.Video ?? mediaType?.Videos;
  306. if (imageType && videoType) {
  307. return [imageType, videoType];
  308. }
  309. return imageType ?? videoType ?? ['images', 'videos'];
  310. }
  311. function isVideoUri(uri: string) {
  312. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  313. }
  314. function normalizeMediaUri(uri?: string | null) {
  315. if (typeof uri !== 'string') return null;
  316. const trimmed = uri.trim();
  317. return trimmed ? trimmed : null;
  318. }
  319. function uniqueMediaUris(uris: string[]) {
  320. const seen = new Set<string>();
  321. const result: string[] = [];
  322. for (const uri of uris) {
  323. if (!uri || seen.has(uri)) continue;
  324. seen.add(uri);
  325. result.push(uri);
  326. }
  327. return result;
  328. }
  329. function parseTaskMeta(raw: string | null) {
  330. if (!raw) return {} as { photoUri?: string };
  331. try {
  332. return JSON.parse(raw) as { photoUri?: string };
  333. } catch {
  334. return {} as { photoUri?: string };
  335. }
  336. }
  337. function serializeTaskMeta(meta: { photoUri?: string }) {
  338. if (!meta.photoUri) return null;
  339. return JSON.stringify(meta);
  340. }
  341. const styles = StyleSheet.create({
  342. container: {
  343. flex: 1,
  344. },
  345. keyboardAvoid: {
  346. flex: 1,
  347. },
  348. content: {
  349. padding: 16,
  350. gap: 10,
  351. paddingBottom: 40,
  352. },
  353. input: {
  354. borderRadius: 10,
  355. borderWidth: 1,
  356. paddingHorizontal: 12,
  357. paddingVertical: 10,
  358. fontSize: 15,
  359. },
  360. mediaPreview: {
  361. width: '100%',
  362. height: 220,
  363. borderRadius: 12,
  364. backgroundColor: '#1C1C1C',
  365. },
  366. photoRow: {
  367. flexDirection: 'row',
  368. gap: 8,
  369. },
  370. actions: {
  371. marginTop: 12,
  372. flexDirection: 'row',
  373. justifyContent: 'flex-end',
  374. alignItems: 'center',
  375. gap: 10,
  376. },
  377. photoPlaceholder: {
  378. opacity: 0.6,
  379. },
  380. mediaStrip: {
  381. marginTop: 6,
  382. },
  383. mediaChip: {
  384. width: 72,
  385. height: 72,
  386. borderRadius: 10,
  387. marginRight: 8,
  388. overflow: 'hidden',
  389. backgroundColor: '#E6E1D4',
  390. alignItems: 'center',
  391. justifyContent: 'center',
  392. },
  393. mediaThumb: {
  394. width: '100%',
  395. height: '100%',
  396. },
  397. videoThumb: {
  398. width: '100%',
  399. height: '100%',
  400. backgroundColor: '#1C1C1C',
  401. alignItems: 'center',
  402. justifyContent: 'center',
  403. },
  404. videoThumbText: {
  405. color: '#FFFFFF',
  406. fontSize: 18,
  407. fontWeight: '700',
  408. },
  409. mediaRemove: {
  410. position: 'absolute',
  411. top: 4,
  412. right: 4,
  413. width: 18,
  414. height: 18,
  415. borderRadius: 9,
  416. backgroundColor: 'rgba(0,0,0,0.6)',
  417. alignItems: 'center',
  418. justifyContent: 'center',
  419. },
  420. mediaRemoveText: {
  421. color: '#FFFFFF',
  422. fontSize: 12,
  423. lineHeight: 14,
  424. fontWeight: '700',
  425. },
  426. updateGroup: {
  427. flexDirection: 'row',
  428. alignItems: 'center',
  429. gap: 8,
  430. },
  431. inlineToastText: {
  432. fontWeight: '700',
  433. fontSize: 12,
  434. },
  435. statusRow: {
  436. flexDirection: 'row',
  437. alignItems: 'center',
  438. justifyContent: 'space-between',
  439. marginBottom: 12,
  440. },
  441. });